iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

Kotlin Ktor 實戰 101系列 第 25 篇

Kotlin Ktor 實戰 101 Day 25 用 Testcontainers 測真的資料庫

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

day 24 結尾說整張測試手法表最大的問題是 day 23 留下來的那個,「測試跑 H2 而正式跑 PostgreSQL」

這篇用 Testcontainers 把它補上,讓資料庫測試對著一個真的 PostgreSQL 跑,容器要起幾次、測試之間怎麼隔離、每種隔離手段各要付多少錢,都實際量過再選,最後再看 H2 跟 PostgreSQL 2 套測試擺在一起的時候,各自的中位數落在哪裡

這篇要完成什麼

  • Testcontainers 2.0 換了 artifact 名字跟 package,網路上那套 1.x 的 Kotlin 寫法編不過
  • 容器要起幾次,@Container 掛在哪個位置決定這件事
  • 測試隔離的 3 條路,量下來差好幾個數量級,其中一條在 API 測試上不成立
  • 一個容器加一句 TRUNCATE,完整的 helper 長什麼樣
  • day 23 那 3 個手動跑出來的 PostgreSQL 行為,加上那個漂移檢查,全部變成測試
  • TITLE_MAX_LENGTH 那筆從 day 14 掛到現在的待辦,這篇做決定
  • 改完之後 H2 那邊多了一個洞,也寫成測試
  • 2 套測試怎麼分,一張實測的時間表
  • CI 那一關,Docker、image、reuse 各自的代價
  • 跟 Relix 的對照,每個測試都建新的東西什麼時候會變貴

相依跟網路上的範例對不上

先查版本,org.testcontainers:testcontainers 這個 core artifact 目前是 2.0.5,2026 年 4 月發的,但同一個 groupId 底下的 org.testcontainers:postgresql 停在 1.21.4,Maven Central 上沒有它的 2.x

2.0 把每個模組的 artifactId 都加了 testcontainers- 前綴,而且每個容器類別搬進自己的 package,所以要裝的是這 2 個,build.gradle.kts 的 dependencies 區塊加

testImplementation("org.testcontainers:testcontainers-postgresql:2.0.5")
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")

testcontainers-postgresql 這個 jar 裡 2 個 package 都在,舊的 org.testcontainers.containers.PostgreSQLContainer 還留著,class 檔上有 Deprecated: true,新的是 org.testcontainers.postgresql.PostgreSQLContainer,沒有標 deprecated

兩者差別不只是搬家,舊的長這樣

public class org.testcontainers.containers.PostgreSQLContainer<SELF extends org.testcontainers.containers.PostgreSQLContainer<SELF>> extends org.testcontainers.containers.JdbcDatabaseContainer<SELF> {

新的把那個 self type 拿掉了

public class org.testcontainers.postgresql.PostgreSQLContainer extends org.testcontainers.containers.JdbcDatabaseContainer<org.testcontainers.postgresql.PostgreSQLContainer> {

Kotlin 這邊網路上流傳最廣的 singleton 寫法就是靠那個 self type,object X : PostgreSQLContainer<X>(...),照抄到新 package 上會拿到

e: file:///Users/cash/Downloads/ktor/todo-api/src/test/kotlin/com/cashwu/todo/SelfTypeSpike.kt:5:46 No type arguments expected for class PostgreSQLContainer : JdbcDatabaseContainer<PostgreSQLContainer!>.

無參數的建構子也一起沒了,image 名稱現在是必填,這件事其實是好的,新類別裡的 DEFAULT_TAG 常數是 "9.6.12",那是 2019 年的 PostgreSQL,沒有人應該對著它跑測試

容器要起幾次

testcontainers-junit-jupiter 提供 @Testcontainers 跟 @Container 2 個註解,容器的 start() 跟 stop() 不用自己寫,@Testcontainers 是一個 JUnit 5 的 extension,它會去找這個 class 裡被 @Container 標到的欄位,在該跑的時候把容器拉起來、跑完再關掉

容器起幾次,由 @Container 掛在哪決定,先看掛在 instance 屬性上的版本,寫一個純粹用來觀察的 spike,src/test/kotlin/com/cashwu/todo/JUnitContainerSpikeTest.kt

@Testcontainers
class JUnitContainerSpikeTest {

    @Container
    val perTest: PostgreSQLContainer = PostgreSQLContainer("postgres:18-alpine")

    @Test
    fun one() {
        println(">>> junit per-test one = ${perTest.jdbcUrl}")
    }

    @Test
    fun two() {
        println(">>> junit per-test two = ${perTest.jdbcUrl}")
    }
}

2 個測試什麼都不驗,只印出各自拿到的 JDBC URL,因為容器的 port 是 Testcontainers 隨機挑一個沒人用的對出來的,同一個 port 就是同一個容器

JUnit 5 每執行一個測試方法都會重新 new 一個 class 的 instance,所以 perTest 這個屬性也是一人一份,@Testcontainers 對著 2 個不同的欄位各起一個容器

>>> junit per-test one = jdbc:postgresql://localhost:32816/test?loggerLevel=OFF
>>> junit per-test two = jdbc:postgresql://localhost:32817/test?loggerLevel=OFF

port 不一樣,是 2 個容器

把同一個欄位搬進 companion object,其他都不動

@Testcontainers
class JUnitSharedContainerSpikeTest {

    companion object {
        @Container
        val shared: PostgreSQLContainer = PostgreSQLContainer("postgres:18-alpine")
    }

    @Test
    fun one() {
        println(">>> junit shared one = ${shared.jdbcUrl}")
    }

    @Test
    fun two() {
        println(">>> junit shared two = ${shared.jdbcUrl}")
    }
}

就變成一個 class 一個容器,第 1 個測試跑之前起來,整個 class 跑完才關掉,2 個測試拿到同一個 port

>>> junit shared one = jdbc:postgresql://localhost:32818/test?loggerLevel=OFF
>>> junit shared two = jdbc:postgresql://localhost:32818/test?loggerLevel=OFF

Kotlin 這邊有個地方要注意,Java 的範例會告訴你 class 層級的容器欄位必須是 static,翻成 Kotlin 直覺會加 @JvmStatic,實測不加也可以,因為 companion object 的 val 本來就會在外層 class 上產生一個 static 的 backing field,Testcontainers 找的是欄位而不是 getter

順帶一提,上面那 2 段的資料庫名稱都是 test 而不是 todo,那是 PostgreSQLContainer 沒有呼叫 withDatabaseName 時的預設值

起一個容器要多久,另外寫一個只做這件事的 spike,src/test/kotlin/com/cashwu/todo/ContainerStartBenchTest.kt,連開 3 次,每次只計時 start() 那一段,量完就關掉

class ContainerStartBenchTest {

    @Test
    fun `how long does one container take to start`() {
        repeat(3) {
            val container = PostgreSQLContainer("postgres:18-alpine")
            val elapsed = measureTimeMillis { container.start() }
            println(">>> bench container start = $elapsed ms")
            container.stop()
        }
    }
}

這裡沒有掛 @Testcontainers,因為要自己決定計時的起點跟終點,容器就自己 start()、自己 stop()

>>> bench container start = 2461 ms
>>> bench container start = 2470 ms
>>> bench container start = 2474 ms

這一輪是 2 秒半,另一輪量到的是 2880、3222、2964 毫秒,3 秒上下這個量級就是每個測試一個容器要付的錢

測試隔離的三條路

@Container 那個選擇背後其實是隔離策略的選擇,這一節量的一樣是機制本身而不是真的測試,3 條路各寫成一個 spike,再加上現在 H2 那套當對照,選中的那一條下一節才會變成正式的 helper,所以下面提到的 TRUNCATE 這時候還只是一句手寫的 SQL

這 4 個 spike 的程式碼這篇不放,它們是量完就刪,不會留在 repo 裡的東西,這一節要的只有那幾個數字跟從數字得到的結論,你不用跟著跑一次,真的會留下來的程式碼是下一節那個 helper,所以下面改用文字把每一個 spike 在做什麼講清楚,數字才有辦法自己看

4 個 spike 的每一輪都做同樣 3 個步驟,把資料庫回到 3 筆種子資料的狀態、insert 1 筆、數出 4 筆,差別只在第 1 步用什麼手段回到那個狀態

  • 每個測試一個容器:每一輪新建一個 PostgreSQLContainer,start()、跑 migration、塞 3 筆種子資料,那一輪跑完 stop()
  • 一個容器加 TRUNCATE:容器跟連線池整組共用,每一輪下一句 TRUNCATE todos RESTART IDENTITY,再把 3 筆種子資料塞回去
  • 一個容器加 rollback:容器跟連線池也共用,每一輪的 insert 跟 count 包在同一個交易裡,做完就 rollback,資料庫等於沒被動過
  • H2 的 withDatabase:day 20 起一直在用的那個 helper,每一輪換一個沒用過的 in-memory 資料庫名稱,重開連線池、重跑 migration、塞 3 筆種子資料

計時從第 1 步就開始算,因為要比的正是回到乾淨狀態要付多少錢,每個 spike 連跑 8 輪,表格裡的數字是那 8 輪加起來的總時間,整組又各量了 2 次

做法 8 輪總計,第 1 次 8 輪總計,第 2 次
每個測試一個容器 24724 ms 25778 ms
一個容器加 TRUNCATE 119 ms 108 ms
一個容器加 rollback 8 ms 9 ms
H2 的 withDatabase 361 ms 362 ms

除以 8 換算成單一個測試,容器 3.1 秒、TRUNCATE 15 毫秒、rollback 1 毫秒、H2 45 毫秒,這幾個數字每次跑都不一樣,但差距的量級很穩

最上面那一列直接出局,8 個測試付 25 秒,這個系列現在有 179 個測試

最下面那一列的 rollback 看起來太吸引人了,它只是把交易丟掉,連 disk 都沒碰到,但它有一個前提,被測的程式碼必須跟測試共用同一條連線,todo-api 的 repository 不共用,ExposedTodoRepository 每個查詢都 withContext(Dispatchers.IO) 再開自己的 transaction,而 Exposed 的交易是綁在 thread 上的

實際跑一次就看得出來,外面包一個交易,裡面打一個 POST,最後丟例外讓外層 rollback

>>> created = 201 Created
>>> inside the transaction = 4
>>> after the rollback = 4

那筆資料早就在另一條連線上 commit 掉了,外層的 rollback 撤不掉它,rollback 這條路只對「測試自己開交易、自己下查詢」的那種測試成立,一旦測試是透過 HTTP 打進去的就不成立,這個系列 2 種測試都有,所以不選它

剩下 TRUNCATE,15 毫秒比 H2 那套的 45 毫秒還便宜,而且它擋得住的東西比較多

這裡要補一句話才公平,H2 慢不是因為它是 H2,是因為它現在用的是每個測試建一個新資料庫那套,把 H2 也換成同一組 singleton 加 TRUNCATE,用上面同一個 spike 量到 8 輪 43 到 44 毫秒,一個測試 5 毫秒出頭,比 PostgreSQL 這條還便宜,所以選 PostgreSQL 的理由從來不是快,是它擋得住 H2 擋不住的東西,而換過來之後速度沒有變差,這一項不用付代價

一個容器加一句 TRUNCATE

helper 放在新的 src/test/kotlin/com/cashwu/todo/PostgresSupport.kt,跟 day 17 以來的 TestApp.kt 分開,因為它有自己的生命週期

容器本身是一個 by lazy 的頂層屬性,第 1 個用到它的測試把它拉起來,之後所有測試共用

val todoPostgres: PostgreSQLContainer by lazy {
    PostgreSQLContainer("postgres:18-alpine")
        .withDatabaseName("todo")
        .withUsername("todo")
        .withPassword("todo")
        .apply { start() }
}

image 標籤跟 day 23 那個 compose.yaml 用的是同一個,postgres:18-alpine,使用者名稱、密碼、資料庫名稱也刻意跟 Compose 對齊,這樣本機用 Compose 手動玩跟測試裡跑的是同一組設定

沒有寫 stop(),Testcontainers 起 app 容器之前會先起一個叫 ryuk 的看門容器,JVM 結束的時候由它負責清掉,log 裡那句話講得很清楚

Ryuk started - will monitor and terminate Testcontainers containers on JVM exit

等測試的 JVM 真的結束之後,docker ps -a --filter "label=org.testcontainers=true" 是空的,確實清乾淨了,gradle 指令剛回來的那幾秒還看得到那 2 個容器,ryuk 是等 JVM 退出才動手,不用以為是壞了

接著是連線池,同一個檔案往下

val postgresConfig: DatabaseConfig
    get() = DatabaseConfig(
        url = todoPostgres.jdbcUrl,
        driver = "org.postgresql.Driver",
        user = todoPostgres.username,
        password = todoPostgres.password,
        poolSize = 4,
    )

val postgresSource: HikariDataSource by lazy {
    todoDataSource(postgresConfig).also { it.migrate() }
}

todoDataSource 跟 migrate() 都是 day 20 到 day 23 寫在 TodoDatabase.kt 的東西,這裡一行新的都不用寫,那個 .also { it.migrate() } 不能省,因為底下的清資料要先有表可以清

清資料的部分還是同一個檔案往下,形狀跟 H2 版的 withDatabase 對齊

fun truncateTodos() {
    postgresSource.connection.use { it.createStatement().execute("TRUNCATE todos RESTART IDENTITY") }
}

fun <T> withPostgres(seed: List<Todo> = defaultTodos, block: (Database) -> T): T {
    truncateTodos()
    return block(postgresSource.connectAndMigrate(seed))
}

RESTART IDENTITY 是重點,少了它,todos_id_seq 會一路往上加,下一個測試的第 1 筆種子資料就不是 id 1 了,day 20 到 day 23 那批「POST 之後拿到 id 4」的測試全部靠 id 從 1 開始,這一句話讓它們在 PostgreSQL 上也成立

connectAndMigrate(seed) 是 day 23 那個表空著才塞種子資料的函式,剛清完當然是空的,它每次會多跑一趟 Flyway 的驗證,也就是 Successfully validated 1 migration 加 Schema "public" is up to date 那 2 句,上面量到的 15 毫秒有一部分花在這裡,留著是因為這樣 withPostgres 跟 withDatabase 對外的樣子完全一樣,測試搬過來不用改

這個 helper 有一個不能省略的前提,使用它的測試必須序列執行,容器、連線池與資料表都是整個 test process 共用,一個測試執行 TRUNCATE 時,另一個測試如果正在讀寫同一張表,隔離就失效,JUnit 預設沒有開平行執行,所以這篇的量測成立,如果之後在 JUnit 或 Gradle 開平行測試,得加 lock、改成每個測試獨立 schema,或拆成不同 database

最後是 API 那一層,也是 PostgresSupport.kt 的最後一段,h2Database 有的東西 PostgreSQL 這邊也要有一份

val postgresDatabase: MutableMap<String, String>.() -> Unit = {
    put("todo.database.url", todoPostgres.jdbcUrl)
    put("todo.database.driver", "org.postgresql.Driver")
    put("todo.database.user", todoPostgres.username)
    put("todo.database.password", todoPostgres.password)
}

fun ApplicationTestBuilder.postgresApplication() {
    truncateTodos()
    configure(overrides = postgresDatabase)
    serverConfig { developmentMode = false }
}

4 個 key 蓋掉 application.yaml,機制就是 day 24 拆過的 fileConfigs.mergeWith(mapConfig),postgresApplication() 跟 day 17 的 todoApplication() 站在同一個位置,差別只有它先清一次桌子

day 23 那三個行為進測試了

day 23 收尾的時候寫,「這篇量到的 PostgreSQL 行為裡,只有四捨五入因為 H2 也吃同一份 DDL 而順便進了測試,其他 3 個 (varchar 數 code point、offset 不留、readOnly 會擋) 全部是手動跑出來的,./gradlew test 一個都碰不到」

現在碰得到了,建立新的 src/test/kotlin/com/cashwu/todo/PostgresBehaviourTest.kt

這一節底下每一段都是這個檔案裡完整的一個測試,可以直接複製貼上,先是 class 開頭共用的 3 樣東西,一個字串跟 2 個 private helper

class PostgresBehaviourTest {

    private val hundred = "🎉".repeat(100)

    private fun Database.insertTitle(title: String) = transaction(this) {
        Todos.insert {
            it[Todos.title] = title
            it[done] = false
            it[createdAt] = FIXED_NOW.atOffset(ZoneOffset.UTC)
        }
    }

    private fun Database.rawCreatedAt(): List<String> = transaction(this) {
        val values = mutableListOf<String>()
        exec("SELECT created_at::text FROM todos ORDER BY id") { rows ->
            while (rows.next()) values.add(rows.getString(1))
        }
        values
    }
}

insertTitle 就是包一句 Todos.insert,rawCreatedAt 用 created_at::text 把欄位當字串讀回來,繞過 driver 的型別轉換

varchar 的部分,100 個 🎉 是 200 個 code unit、100 個 code point,H2 會擋、PostgreSQL 收得下

@Test
fun `a hundred emoji pass the exposed check and postgres takes them`() {
    withPostgres(seed = emptyList()) { database ->
        database.insertTitle(hundred)

        assertEquals(100, hundred.codePointCount(0, hundred.length))
        assertEquals(200, hundred.length)

        val stored = transaction(database) { Todos.selectAll().single()[Todos.title] }

        assertEquals(100, stored.codePointCount(0, stored.length))
        assertEquals(400, stored.toByteArray(Charsets.UTF_8).size)
    }
}

那個 100 對 400 就是 day 23 用 psql 查出來的 char_length 跟 octet_length,現在是斷言

超過上限那一邊要繞過 Exposed,Exposed 自己那層檢查數的是 code point,跟 PostgreSQL 一樣嚴,101 個字元根本送不到資料庫,所以直接下 SQL

@Test
fun `one code point over the limit is a 22001 from postgres`() {
    withPostgres(seed = emptyList()) { database ->
        val failure = assertFailsWith<ExposedSQLException> {
            transaction(database) {
                exec(
                    "INSERT INTO todos (title, done, created_at) " +
                        "VALUES ('${"a".repeat(101)}', false, now())"
                )
            }
        }

        assertEquals("22001", (failure.cause as SQLException).sqlState)
        assertContains(
            failure.message.orEmpty(),
            "value too long for type character varying(100)",
        )
    }
}

SQLSTATE 22001 是 string_data_right_truncation,day 23 那段 psql 輸出只有訊息本體,現在連錯誤碼一起測到

offset 那個測試塞 2 筆同瞬間、不同 offset 的資料,1 筆 +05:30 1 筆 +08:00

@Test
fun `postgres keeps the instant and throws the offset away`() {
    val instant = Instant.parse("2026-08-27T08:00:00Z")

    withPostgres(seed = emptyList()) { database ->
        transaction(database) {
            Todos.insert {
                it[title] = "加爾各答寫的"
                it[done] = false
                it[createdAt] = instant.atOffset(ZoneOffset.ofHoursMinutes(5, 30))
            }
            Todos.insert {
                it[title] = "台北寫的"
                it[done] = false
                it[createdAt] = instant.atOffset(ZoneOffset.ofHours(8))
            }
        }

        val raw = database.rawCreatedAt()
        val back = transaction(database) { Todos.selectAll().map { it.toTodo().createdAt } }

        assertEquals(raw[0], raw[1])
        assertEquals(List(2) { Instant.parse("2026-08-27T08:00:00Z") }, back)
    }
}

2 個斷言各講一半,第 1 個說資料庫裡看到的 2 筆長得一模一樣,寫進去的 offset 沒有留下來,第 2 個說讀回 Kotlin 是同一個 Instant,瞬間沒有跑掉,這裡刻意不去斷言那個字串長什麼樣,因為 day 23 已經查清楚它是 pgjdbc 照 JVM 時區送的 session time zone,換一台機器就不一樣

readOnly 那個測試等的是 SQLSTATE 25006

@Test
fun `postgres refuses an insert inside a read only transaction`() {
    withPostgres(seed = emptyList()) { database ->
        val failure = assertFailsWith<ExposedSQLException> {
            transaction(database, readOnly = true) {
                Todos.insert {
                    it[title] = "唯讀交易想寫的"
                    it[done] = false
                    it[createdAt] = FIXED_NOW.atOffset(ZoneOffset.UTC)
                }
            }
        }

        assertEquals("25006", (failure.cause as SQLException).sqlState)
        assertContains(
            failure.message.orEmpty(),
            "cannot execute INSERT in a read-only transaction",
        )
        assertEquals(0, transaction(database) { Todos.selectAll().count() })
    }
}

第 3 個斷言是為了 day 23 那個意外,那篇發現 Exposed 透過 DataSource 連線時,第 1 個交易的 readOnly 會被當成這個 DataSource 的基準值快取起來,順序不對就不會擋,withPostgres 的 connectAndMigrate 一定先跑一個非唯讀的交易,所以這裡量到的是擋得住的那條路徑,數出 0 筆是在證明它真的沒寫進去

還有一個測試是 day 23 小結欠的,那篇說「目前 CI 還只跑 H2,正式方言的漂移要等 day 25 用 Testcontainers 納入測試」

@Test
fun `the migration and the table definition do not drift on postgres`() {
    withPostgres(seed = emptyList()) { database ->
        val missing = transaction(database) {
            MigrationUtils.statementsRequiredForDatabaseMigration(Todos)
        }

        assertEquals(emptyList<String>(), missing)
    }
}

emptyList() 這裡要把型別參數寫出來,missing 是 List<String>,但 assertEquals 2 個參數都吃同一個 T,空 list 自己沒有東西可以推,編譯器會回一句 Cannot infer type for type parameter 'T',MigrationTest 那個測試的期望值是 listOf("ALTER TABLE ...") 所以不會碰到

MigrationUtils 跟 day 23 的 MigrationTest 用的是同一個,同一個檢查在 H2 上回的是一句 ALTER TABLE TODOS ALTER COLUMN CREATED_AT TIMESTAMP(9) WITH TIME ZONE NOT NULL,那是 Exposed 的 H2 方言硬寫精度 9 造成的雜訊,MigrationTest 裡那個測試只好把那句話寫死在期望值裡,PostgreSQL 這邊是乾淨的空 list,2 個測試放在一起才是完整的漂移檢查,一個看得到真正的落差,一個負責在方言雜訊變了的時候出聲

validation 那筆待辦,改了

這筆待辦從 day 14 掛到現在,原話是「現在用的 String.length 算的是 UTF-16 code unit,不完全等於使用者看見的字數,碰到 emoji 時可能更早超過 100,day 20 用 Exposed 建表時要再確認資料庫的長度規則,決定是否改用 code point 或 grapheme cluster 計數」

day 20 的結論是不用改,因為「String.length 數的單位跟 H2 的 VARCHAR(100) 一樣,而且 code point 數永遠小於等於 code unit 數,validation 這一關過得了的東西,H2 一定收得下」,day 23 換上 PostgreSQL 之後那個前提沒了,但那篇還是不改,理由換成「改了以後沒有測試能證明它是對的」

現在有測試了,所以改動的是 src/main/kotlin/com/cashwu/todo/TodoValidation.kt 裡的 validateTitle

private fun validateTitle(title: String): ValidationResult {
    val reasons = mutableListOf<String>()
    if (title.isBlank()) {
        reasons.add("title 不能是空白")
    }
    if (title.codePointCount(0, title.length) > TITLE_MAX_LENGTH) {
        reasons.add("title 長度不能超過 $TITLE_MAX_LENGTH 個字")
    }
    return if (reasons.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(reasons)
}

整個函式貼在這裡,但真正改的只有中間那個 if,title.length 換成 title.codePointCount(0, title.length),正式程式碼這篇就只動這一行

為什麼是 code point 而不是 day 14 提到的另一個選項 grapheme cluster,因為 code point 正好是 PostgreSQL 的 varchar(100) 數的單位,改成它之後 validation 跟資料庫看到的是同一個數字,grapheme cluster 會比資料庫寬鬆,一個家庭 emoji 在 grapheme 眼裡是一格、在 PostgreSQL 眼裡是好幾格,選它等於把現在這個問題原封不動翻到另一邊

證明它是對的那個測試在新的 src/test/kotlin/com/cashwu/todo/PostgresRoutesTest.kt,hundred 一樣是這個 class 的 private 屬性

@Test
fun `a hundred emoji title goes through the whole stack`() = testApplication {
    postgresApplication()

    val created = client.post("/todos") {
        contentType(ContentType.Application.Json)
        setBody("""{"title":"$hundred"}""")
    }

    assertEquals(HttpStatusCode.Created, created.status)

    val stored = Json.decodeFromString<Todo>(created.bodyAsText()).title

    assertEquals(100, stored.codePointCount(0, stored.length))
    assertEquals(hundred, stored)
}

day 23 手動打這個請求拿到的是 400,現在是 201,而且東西真的進了 PostgreSQL 再原樣讀回來

上限那一邊也要有測試驗到,多一個 code point 就該被擋下來

@Test
fun `one code point over the limit is still a 400`() = testApplication {
    postgresApplication()

    val response = client.post("/todos") {
        contentType(ContentType.Application.Json)
        setBody("""{"title":"${"🎉".repeat(101)}"}""")
    }

    assertEquals(HttpStatusCode.BadRequest, response.status)
    assertContains(response.bodyAsText(), "title 長度不能超過 100 個字")
    assertEquals(3, client.todoCount())
}

最後那個斷言是在確認被擋下來的請求沒有留下任何東西,資料庫還是 3 筆

同一個檔案裡另一個測試盯的是 day 20 就講過、day 22 又講一次的那件事,資料不會自己消失

@Test
fun `what one application writes the next one still sees`() {
    testApplication {
        postgresApplication()

        client.post("/todos") {
            contentType(ContentType.Application.Json)
            setBody("""{"title":"上一個 application 寫的"}""")
        }

        assertEquals(4, client.todoCount())
    }

    testApplication {
        configure(overrides = postgresDatabase)
        serverConfig { developmentMode = false }

        assertEquals(4, client.todoCount())
        assertContains(client.get("/todos").bodyAsText(), "上一個 application 寫的")
    }
}

第 2 個 testApplication 刻意不用 postgresApplication(),因為那個 helper 開頭會清桌子,這裡要的正好是不清,DependencyInjectionTest 裡那個 every application gets its own todo list 斷言第 1 個 application 是 4 筆、第 2 個是 3 筆,這個測試在同一個位置斷言 4 筆跟 4 筆,2 個都留著,各自描述自己那個資料庫

H2 那邊反而破了一個洞

day 20 排出來的長度規則有 3 層,Kotlin 的 validation、Exposed 的 client 端檢查、資料庫自己,那篇的結論是最嚴的剛好排在最前面,所以什麼都不用改,改完 validation 之後,最嚴的那一層在測試環境裡變成 H2 自己,而它排在最後面,100 個 emoji 現在過得了 validation、過得了 Exposed 的 code point 檢查,然後撞上 H2 的 VARCHAR(100) 數 code unit

這件事也寫成測試,就放在 PostgresRoutesTest 裡上面那個測試的隔壁

@Test
fun `the same title is a 500 when the test runs on h2`() = testApplication {
    todoApplication()

    val response = client.post("/todos") {
        contentType(ContentType.Application.Json)
        setBody("""{"title":"${"🎉".repeat(100)}"}""")
    }

    assertEquals(HttpStatusCode.InternalServerError, response.status)
    assertEquals(
        """{"status":500,"message":"伺服器發生未預期的錯誤","details":[]}""",
        response.bodyAsText(),
    )
}

一個 H2 的測試放在一個叫 PostgresRoutesTest 的檔案裡看起來很怪,但它跟上面那個測試只有並排在一起才有意義,同一個請求,一邊 201 一邊 500,中間差的是資料庫,這個 500 是測試替身與正式資料庫的已知落差,不是 API 接受的行為

要不要花力氣去把 H2 調得跟 PostgreSQL 一樣,這篇沒有試,因為本尊已經在測試裡了,這個測試的名字要明確寫出 H2 dialect mismatch,避免把 500 誤認成產品 contract,真正的標題長度邊界只由 PostgreSQL 那組測試擔保

副作用要寫下來,不然它只存在我腦子裡,留在 H2 上的那 23 個 TodoRoutesTest 從現在起不能拿 emoji 當標題,一拿就會撞上這個 500,真的要驗 emoji 的,寫在 PostgreSQL 那邊

兩套測試怎麼分

現在同一個 API 有 2 套測試,上面那張隔離實驗的表量的是隔離機制本身,這張量的是真的測試,這一輪 ./gradlew test 產生的 XML 報告直接讀出來

測試種類 個數 中位數
H2 資料庫層 TodoDatabaseTest 8 40 ms
H2 API 層 TodoRoutesTest 23 43 ms
PostgreSQL 資料庫層 PostgresBehaviourTest 6 21 ms
PostgreSQL API 層 PostgresRoutesTest 4 243 ms

2 個地方要說明,PostgresBehaviourTest 排在最前面的那個測試是 3254 毫秒,容器的錢由它一個人付掉,其他 5 個是 14 到 57 毫秒,PostgresRoutesTest 實際上有 5 個測試,第 5 個跑在 H2 上,不算在這一列裡

資料庫層那 2 列值得停一下,PostgreSQL 反而比較便宜,API 層倒過來,慢了 5 倍多,原因在 log 裡看得到

17:24:53.850 INFO  [no-call-id] c.z.h.HikariDataSource -- todo-pool - Starting...
17:24:53.877 INFO  [no-call-id] c.z.h.p.HikariPool -- todo-pool - Added connection org.postgresql.jdbc.PgConnection@7bb8c4d1
17:24:53.877 INFO  [no-call-id] c.z.h.HikariDataSource -- todo-pool - Start completed.
17:24:54.050 INFO  [no-call-id] c.z.h.HikariDataSource -- todo-pool - Shutdown initiated...
17:24:54.073 INFO  [no-call-id] c.z.h.HikariDataSource -- todo-pool - Shutdown completed.

每一個 testApplication 都會讓 module() 建一個自己的 HikariCP 池、跑一次 Flyway 驗證,然後在 application 停止的時候關掉,day 20 小結講過這件事,「連線池是 AutoCloseable,application 停止的時候容器自己關掉,day 18 那個機制接上了真的資源」,機制沒有問題,代價是連上一台真的資料庫比連上 in-memory H2 貴

順帶一提,這也是這批測試不會把資料庫的連線數吃光的原因,連續跑 6 個 testApplication,每次結束後數 pg_stat_activity,這一輪從頭到尾都是 4,也就是 postgresSource 自己那 4 條

所以分工是這樣,要驗的是 route、plugin、序列化、狀態碼這些跟資料庫沒關係的行為,留在 H2,那 23 個 TodoRoutesTest 不需要搬,照上面 2 個中位數的差算,搬過去要多花 4 秒多,換來的東西是零,要驗的是資料庫真的怎麼回應,寫成 PostgreSQL 測試

有一個東西沒有搬,但它其實應該搬,ExposedTodoRepositoryTest 那些測試打的是真的 SQL,卻跑在 H2 上,也就是說 repository 這一層的正確性目前是由一個不會上線的資料庫背書的,這篇沒有搬是因為裡面 2 個測試綁著 in-memory H2 的特性 (連線池飽和、withMigratedSchema 那個 2 條連線的把戲),搬過去要重寫,而重寫一批已經通過的測試沒辦法在這篇一起講清楚

h2Database 跟 withMigratedSchema 這 2 個 day 24 說「下一篇換掉資料庫之後還留不留得住,那時候才知道」的 helper,答案是都留住了,而且多了 2 個並排的鄰居

CI 那一關

這套東西要能在 CI 上跑,前提是 CI 上有 Docker daemon,GitHub Actions 的 ubuntu-latest 有,大部分託管的 runner 也有,但用 Kubernetes 起 runner 的環境就得自己確認,那是 Testcontainers 最常卡住的地方

第 2 件事是 image,docker images 顯示這台機器上的 postgres:18-alpine 解開之後是 304MB,CI 上第 1 次跑要下載,實際傳輸的是壓縮過的量,比這個數字小,ryuk 那個看門容器也要下載,這一輪唯一一次真的量到的 pull 是它

17:14:55.795 INFO  [no-call-id] t.t.14.0 -- Pulling docker image: testcontainers/ryuk:0.14.0. Please be patient; this may take some time but only needs to be done once.

同一段的最後 2 行

17:14:58.604 INFO  [no-call-id] t.t.14.0 -- Pulling image layers:  0 pending,  2 downloaded,  2 extracted, (2 MB/2 MB)
17:14:58.636 INFO  [no-call-id] t.t.14.0 -- Image testcontainers/ryuk:0.14.0 pull took PT2.84156S

ryuk 只有 2MB 就要 2.8 秒,PostgreSQL 那顆的時間量不到,因為 day 23 已經把它拉下來了,這一段沒有數字可以給,但方向很清楚,CI 要嘛快取 Docker layer、要嘛接受每次跑多花那段下載時間

第 3 件事是 reuse,withReuse(true) 可以讓容器在測試結束之後留著,下一次直接接上去,那 2 秒半的啟動就省掉了,但它預設不生效

17:21:52.862 WARN  [no-call-id] t.postgres:18-alpine -- Reuse was requested but the environment does not support the reuse of containers
To enable reuse of containers, you must set 'testcontainers.reuse.enable=true' in a file located at /Users/cash/.testcontainers.properties

那個開關刻意放在使用者家目錄而不是專案裡,因為留下來的容器是機器層級的狀態,CI 上不該開,每一次 build 都要從乾淨的狀態開始才有意義,本機開起來會很舒服,代價是你得自己記得那個容器裡有上一次跑剩下的東西,這篇沒有開,TRUNCATE 已經讓每個測試的起點是確定的,剩下那 2 秒半是整個 test task 只付一次的錢

還有一個這台機器上才看得到的警告

17:25:17.294 WARN  [no-call-id] t.postgres:18-alpine -- The architecture 'amd64' for image 'postgres:18-alpine' (ID sha256:b07129cc272f688c98f5b343138a0a52fa45b3d82f50d7a53ff441330624cd2e) does not match the Docker server architecture 'arm64'. This will cause the container to execute much more slowly due to emulation and may lead to timeout failures.

本機那顆 postgres:18-alpine 是 amd64 的,機器是 arm64,所以整個容器跑在模擬層上,day 23 那些 poolSize 的數字也是在同一顆 image 上量的,所以這篇跟前一篇至少是一致的,上面跟容器有關的絕對時間因此偏悲觀,換成原生架構的 image 通常會更快,但不同負載受模擬層影響的幅度不同,效能曲線要重新量過才能下結論

最後一件事這篇選擇承受,從這篇之後 ./gradlew test 硬性相依 Docker,一台沒裝 Docker 的機器連 H2 那 168 個都跑不完,因為 test task 是一起跑的,要分開的話可以用 JUnit tag 把容器測試標出來另外跑,或在 PostgresSupport.kt 加 assumeTrue(DockerClientFactory.instance().isDockerAvailable),如果改用 Testcontainers 的 JUnit extension,官方也提供 @Testcontainers(disabledWithoutDocker = true),這篇維持手動 singleton,所以沒有直接套那個 annotation,一個有貢獻者的專案應該明確選一種策略

跟 Relix 的對照

Relix 沒有做到資料庫,也就沒有容器要管,但它在同一個決定上選了另一邊

Relix day 28 說,「每個 relixTest { } block 建立全新的 RelixApplication,互不干擾,這是刻意的,測試隔離比效能重要,如果 test A 的 plugin 設定影響了 test B,debug 會非常痛苦」

這個判斷在那個情境下完全正確,因為手刻框架的 app 只是幾個 map 跟 list,建一個的成本接近零,同一句話換到這篇的情境就會變成每個測試 3 秒,有意思的是那篇自己也留了但書,「Relix app 本身只配置少量 map 與 list,但測試速度仍取決於你註冊的 service、外部資源與 runner,如果 suite 變慢,先量測 setup 與 handler 各自耗時」

那個「外部資源」就是這篇,「測試隔離比效能重要」不是一個永遠成立的原則,它成立的前提是隔離很便宜,當隔離變貴的時候,要做的不是放棄隔離,是換一個便宜的隔離手段,測試維持序列執行、schema 沒有在測試中改變時,TRUNCATE RESTART IDENTITY 可以提供這個系列需要的隔離,平行執行時就不成立

Relix 的另一半答案在 day 29,那篇對自己的 InMemoryTodoRepository 講得很坦白,「拿來跑測試跟系列範例的,並行安全只做了一半」,結論是「真實應用要嘛用 ConcurrentHashMap + 原子操作,要嘛就交給具有交易語意的資料庫 repository」,手刻那個系列停在這裡是對的,而這個系列走進去之後看到的就是這篇量的這些東西


小結

Testcontainers core 現在是 2.0.5,PostgreSQL 模組要用 org.testcontainers:testcontainers-postgresql:2.0.5,類別搬到 org.testcontainers.postgresql,新版拿掉 self type,所以舊的 Kotlin singleton 寫法不能直接照搬

這一輪最後選一個共用容器加 TRUNCATE todos RESTART IDENTITY,它比每個測試啟動容器快很多,也讓 id 回到 1,但前提是測試維持序列執行,平行測試必須改用 lock、獨立 schema 或獨立 database,rollback 雖然更快,API 測試會在另一條連線提交交易,外層 rollback 撤不掉

day 23 的 PostgreSQL 行為都進了測試,標題長度也改用 code point,H2 對同一個 100 個 emoji 請求仍回 500,這是測試替身的已知差異,不是 API contract,效能數字受到 amd64 image 在 arm64 上模擬執行影響,換原生架構後要重新量測


下一篇

下一篇講 Authentication,todo-api 到現在為止是完全公開的,任何人都可以刪掉任何一筆待辦,要處理的是 Ktor 的 Authentication plugin 怎麼裝、bearer provider 拿到 token 之後要做什麼、驗證失敗的時候那個 401 是誰回的,還有一件跟這篇有關的事,認證這一層一旦進來,前面那 179 個測試每一個都要帶著身分才打得進去


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 24 testApplication 深入
下一篇
Kotlin Ktor 實戰 101 Day 26 Authentication 架構與 Bearer
系列文
Kotlin Ktor 實戰 101 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言